agentmux_srv\backend\storage/
def_registry_mirror.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Global agent-definition registry mirror.
5//!
6//! Mirrors `db_agent_definitions` mutations (with the agent's content +
7//! skills) into the GLOBAL definition store at
8//! `~/.agentmux/shared/agents/definitions/` so an agent created in one
9//! channel is visible in every channel. SQLite remains authoritative for
10//! the local channel; the global store is the cross-channel view.
11//! Best-effort: every failure is logged, never propagated. Sibling of
12//! `registry_mirror.rs` (the named-instance mirror).
13//!
14//! Cross-channel agent persistence, P0.2b
15//! (`docs/specs/SPEC_CROSS_CHANNEL_AGENT_PERSISTENCE_2026-06-13.md`).
16
17use crate::registry::{
18    DefContentBlob, DefSkillBlob, DefinitionRecord, DefinitionRecordV1, DEF_MAX_SUPPORTED_SCHEMA,
19};
20
21use super::agents::AgentDefinition;
22use super::content::AgentContent;
23use super::skills::AgentSkill;
24use super::store::Store;
25
26/// Build a global definition record from a definition row + its content and
27/// skills (which live in separate tables and must travel with the
28/// definition for a cross-channel agent to launch with its instructions).
29pub(super) fn agent_definition_to_record(
30    def: &AgentDefinition,
31    content: &[AgentContent],
32    skills: &[AgentSkill],
33) -> DefinitionRecord {
34    let data = DefinitionRecordV1 {
35        id: def.id.clone(),
36        slug: def.slug.clone(),
37        name: def.name.clone(),
38        icon: def.icon.clone(),
39        provider: def.provider.clone(),
40        description: def.description.clone(),
41        working_directory: def.working_directory.clone(),
42        shell: def.shell.clone(),
43        provider_flags: def.provider_flags.clone(),
44        auto_start: def.auto_start,
45        restart_on_crash: def.restart_on_crash,
46        idle_timeout_minutes: def.idle_timeout_minutes,
47        created_at: def.created_at,
48        agent_type: def.agent_type.clone(),
49        environment: def.environment.clone(),
50        agent_bus_id: def.agent_bus_id.clone(),
51        is_seeded: def.is_seeded,
52        accounts: def.accounts.clone(),
53        parent_id: def.parent_id.clone(),
54        branch_label: def.branch_label.clone(),
55        updated_at: def.updated_at,
56        user_hidden: def.user_hidden,
57        container_image: def.container_image.clone(),
58        container_volumes: def.container_volumes.clone(),
59        container_name: def.container_name.clone(),
60        content: content
61            .iter()
62            .map(|c| DefContentBlob {
63                content_type: c.content_type.clone(),
64                content: c.content.clone(),
65            })
66            .collect(),
67        skills: skills
68            .iter()
69            .map(|s| DefSkillBlob {
70                id: s.id.clone(),
71                name: s.name.clone(),
72                trigger: s.trigger.clone(),
73                skill_type: s.skill_type.clone(),
74                description: s.description.clone(),
75                content: s.content.clone(),
76            })
77            .collect(),
78    };
79    DefinitionRecord {
80        schema_version: DEF_MAX_SUPPORTED_SCHEMA,
81        data,
82    }
83}
84
85/// Reconstruct an `AgentDefinition` from a global record (inverse of
86/// [`agent_definition_to_record`]) — the read path for a cross-channel
87/// agent whose definition isn't in the local channel's SQLite. Content +
88/// skills are carried separately on the record and surfaced via the
89/// content/skills read fallbacks.
90pub(super) fn record_to_agent_definition(rec: &DefinitionRecord) -> AgentDefinition {
91    let d = &rec.data;
92    AgentDefinition {
93        id: d.id.clone(),
94        slug: d.slug.clone(),
95        name: d.name.clone(),
96        icon: d.icon.clone(),
97        provider: d.provider.clone(),
98        description: d.description.clone(),
99        working_directory: d.working_directory.clone(),
100        shell: d.shell.clone(),
101        provider_flags: d.provider_flags.clone(),
102        auto_start: d.auto_start,
103        restart_on_crash: d.restart_on_crash,
104        idle_timeout_minutes: d.idle_timeout_minutes,
105        created_at: d.created_at,
106        agent_type: d.agent_type.clone(),
107        environment: d.environment.clone(),
108        agent_bus_id: d.agent_bus_id.clone(),
109        is_seeded: d.is_seeded,
110        accounts: d.accounts.clone(),
111        parent_id: d.parent_id.clone(),
112        branch_label: d.branch_label.clone(),
113        updated_at: d.updated_at,
114        user_hidden: d.user_hidden,
115        container_image: d.container_image.clone(),
116        container_volumes: d.container_volumes.clone(),
117        container_name: d.container_name.clone(),
118    }
119}
120
121impl Store {
122    /// Mirror a definition (by id) into the global store, reading its full
123    /// row + content + skills from SQLite. Best-effort: a missing global
124    /// store or any failure is logged, never propagated (SQLite stays
125    /// authoritative). No-op when the definition no longer exists.
126    ///
127    /// `upsert` itself refuses to resurrect a tombstoned id, so a stale
128    /// mirror call for a deleted agent won't un-delete it.
129    pub(super) fn registry_def_upsert(&self, def_id: &str) {
130        let Some(reg) = self.shared_def_registry() else {
131            return;
132        };
133        let def = match self.agent_def_get(def_id) {
134            Ok(Some(d)) => d,
135            Ok(None) => return,
136            Err(e) => {
137                tracing::warn!(def_id, error = %e, "def registry: read definition failed, skipping mirror");
138                return;
139            }
140        };
141        // Only USER agents go to the global cross-channel store. Seeded
142        // templates are manifest-managed and re-seeded per channel (and
143        // their hide flag is per-channel UI state), so they stay local.
144        if def.is_seeded != 0 {
145            return;
146        }
147        // LOCAL-only reads: the mirror operates on a local agent, so it must
148        // never read the global record (that would re-mirror just-deleted
149        // content/skills back, defeating the deletion). (reagent P1 on #1385.)
150        let content = self.agent_content_get_all_local(def_id).unwrap_or_default();
151        let skills = self.agent_skill_list_local(def_id).unwrap_or_default();
152        let rec = agent_definition_to_record(&def, &content, &skills);
153        if let Err(e) = reg.upsert(&rec) {
154            tracing::warn!(def_id, error = %e, "def registry: mirror upsert failed");
155        }
156    }
157
158    /// Mirror a user-agent deletion as a global tombstone (`retired/`) so
159    /// another channel's stale SQLite can't resurrect it via `upsert`.
160    /// Best-effort.
161    pub(super) fn registry_def_retire(&self, def_id: &str) -> bool {
162        let Some(reg) = self.shared_def_registry() else {
163            return false;
164        };
165        // Whether an ACTIVE global record existed — lets the delete path
166        // report success for a cross-channel agent that has no local SQLite
167        // row. (codex P1 on #1385.)
168        let existed = reg.exists(def_id);
169        if let Err(e) = reg.retire(def_id) {
170            tracing::warn!(def_id, error = %e, "def registry: mirror retire failed");
171            return false;
172        }
173        existed
174    }
175
176    /// Apply a definition edit directly to the global record's definition
177    /// fields, PRESERVING its existing content + skills. Used when editing a
178    /// cross-channel agent that has no local SQLite row to update. Returns
179    /// whether a global record was found + updated. Best-effort. Tombstoned
180    /// (retired) agents are not resurrected — `get` reads only the active tree.
181    pub(super) fn registry_def_update_definition_fields(&self, agent: &AgentDefinition) -> bool {
182        let Some(reg) = self.shared_def_registry() else {
183            return false;
184        };
185        let mut rec = match reg.get(&agent.id) {
186            Ok(Some(r)) => r,
187            _ => return false,
188        };
189        // Keep the record's content + skills; replace the definition fields.
190        let kept_content = std::mem::take(&mut rec.data.content);
191        let kept_skills = std::mem::take(&mut rec.data.skills);
192        let mut new_rec = agent_definition_to_record(agent, &[], &[]);
193        new_rec.data.content = kept_content;
194        new_rec.data.skills = kept_skills;
195        if let Err(e) = reg.upsert(&new_rec) {
196            tracing::warn!(def_id = %agent.id, error = %e, "def registry: cross-channel update failed");
197            return false;
198        }
199        true
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::registry::{
207        DefContentBlob, DefSkillBlob, DefinitionRecord, DefinitionRecordV1, DefinitionStore,
208        DEF_MAX_SUPPORTED_SCHEMA,
209    };
210    use std::sync::Arc;
211
212    fn global_user_agent(id: &str, name: &str) -> DefinitionRecord {
213        DefinitionRecord {
214            schema_version: DEF_MAX_SUPPORTED_SCHEMA,
215            data: DefinitionRecordV1 {
216                id: id.to_string(),
217                name: name.to_string(),
218                provider: "claude".to_string(),
219                is_seeded: 0,
220                content: vec![DefContentBlob {
221                    content_type: "agentmd".to_string(),
222                    content: "be helpful".to_string(),
223                }],
224                skills: vec![DefSkillBlob {
225                    id: "sk1".to_string(),
226                    name: "greet".to_string(),
227                    ..Default::default()
228                }],
229                ..Default::default()
230            },
231        }
232    }
233
234    #[test]
235    fn read_first_surfaces_cross_channel_agent_with_content_and_skills() {
236        let store = Store::open_in_memory().unwrap();
237        let tmp = tempfile::tempdir().unwrap();
238        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
239        // A user agent that exists only in the global store (another channel).
240        def_store
241            .upsert(&global_user_agent("remote-1", "Remote"))
242            .unwrap();
243        store.set_def_registry(def_store);
244
245        // Roster includes the cross-channel agent (it's not in local SQLite).
246        let list = store.agent_def_list().unwrap();
247        assert!(
248            list.iter().any(|d| d.id == "remote-1" && d.name == "Remote"),
249            "agent_def_list must include the global-only user agent"
250        );
251
252        // Content + skills fall back to the global record (local SQLite empty),
253        // so the cross-channel agent launches with its instructions.
254        let content = store.agent_content_get_all("remote-1").unwrap();
255        assert_eq!(content.len(), 1);
256        assert_eq!(content[0].content, "be helpful");
257        let skills = store.agent_skill_list("remote-1").unwrap();
258        assert_eq!(skills.len(), 1);
259        assert_eq!(skills[0].name, "greet");
260    }
261
262    fn local_def(id: &str, name: &str) -> AgentDefinition {
263        AgentDefinition {
264            id: id.to_string(),
265            slug: id.to_string(),
266            name: name.to_string(),
267            icon: "✦".to_string(),
268            provider: "claude".to_string(),
269            description: String::new(),
270            working_directory: String::new(),
271            shell: String::new(),
272            provider_flags: String::new(),
273            auto_start: 0,
274            restart_on_crash: 0,
275            idle_timeout_minutes: 0,
276            created_at: 1,
277            agent_type: "host".to_string(),
278            environment: String::new(),
279            agent_bus_id: String::new(),
280            is_seeded: 0,
281            accounts: String::new(),
282            parent_id: String::new(),
283            branch_label: String::new(),
284            updated_at: 1,
285            user_hidden: 0,
286            container_image: String::new(),
287            container_volumes: "[]".to_string(),
288            container_name: String::new(),
289        }
290    }
291
292    fn skill(id: &str, agent_id: &str, name: &str) -> AgentSkill {
293        AgentSkill {
294            id: id.to_string(),
295            agent_id: agent_id.to_string(),
296            name: name.to_string(),
297            trigger: String::new(),
298            skill_type: "prompt".to_string(),
299            description: String::new(),
300            content: String::new(),
301            created_at: 1,
302        }
303    }
304
305    #[test]
306    fn local_skill_delete_does_not_resurrect_from_global() {
307        let store = Store::open_in_memory().unwrap();
308        let tmp = tempfile::tempdir().unwrap();
309        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
310        store.set_def_registry(def_store.clone());
311
312        // Create a LOCAL user agent with one skill (mirrored to global).
313        let mut def = local_def("local-1", "Local");
314        store.agent_def_insert(&mut def).unwrap();
315        store
316            .agent_skill_insert(&skill("sk-1", "local-1", "greet"))
317            .unwrap();
318        assert_eq!(
319            def_store.get("local-1").unwrap().unwrap().data.skills.len(),
320            1,
321            "skill mirrored to global"
322        );
323
324        // Delete the skill in this (local) channel.
325        store.agent_skill_delete("sk-1").unwrap();
326
327        // Local read must NOT resurrect the deleted skill from the global
328        // record (the agent is local, just skill-less now). (reagent P1.)
329        assert!(
330            store.agent_skill_list("local-1").unwrap().is_empty(),
331            "deleted local skill must not reappear from the global record"
332        );
333        // And the deletion propagated to the global record (the mirror used a
334        // local-only read, so it didn't re-write the stale skill).
335        assert!(
336            def_store.get("local-1").unwrap().unwrap().data.skills.is_empty(),
337            "deletion must propagate cross-channel"
338        );
339    }
340
341    #[test]
342    fn local_content_delete_propagates_to_global() {
343        let store = Store::open_in_memory().unwrap();
344        let tmp = tempfile::tempdir().unwrap();
345        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
346        store.set_def_registry(def_store.clone());
347
348        // LOCAL user agent with a per-agent ui:zoom blob (mirrored to global).
349        let mut def = local_def("local-z", "LocalZ");
350        store.agent_def_insert(&mut def).unwrap();
351        store
352            .agent_content_set(&AgentContent {
353                agent_id: "local-z".into(),
354                content_type: "ui:zoom".into(),
355                content: "1.5".into(),
356                updated_at: 1,
357            })
358            .unwrap();
359        assert!(
360            def_store
361                .get("local-z")
362                .unwrap()
363                .unwrap()
364                .data
365                .content
366                .iter()
367                .any(|c| c.content_type == "ui:zoom"),
368            "content mirrored to global"
369        );
370
371        // Reset-to-default deletes the local row.
372        store.agent_content_delete("local-z", "ui:zoom").unwrap();
373
374        // Local read is clean (no resurrection from the global record)...
375        assert!(
376            store.agent_content_get("local-z", "ui:zoom").unwrap().is_none(),
377            "deleted local content must not reappear from the global record"
378        );
379        // ...and the deletion propagated to the global record, so a
380        // cross-channel/other-instance reopen won't restore the stale zoom.
381        // (reviewer P1 on #1700 — agent_content_delete must re-mirror, like set.)
382        assert!(
383            def_store
384                .get("local-z")
385                .unwrap()
386                .unwrap()
387                .data
388                .content
389                .iter()
390                .all(|c| c.content_type != "ui:zoom"),
391            "content deletion must propagate cross-channel"
392        );
393    }
394
395    #[test]
396    fn delete_tombstones_a_cross_channel_only_agent() {
397        let store = Store::open_in_memory().unwrap();
398        let tmp = tempfile::tempdir().unwrap();
399        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
400        // A user agent that exists ONLY in the global store (no local SQLite row).
401        def_store
402            .upsert(&global_user_agent("remote-1", "Remote"))
403            .unwrap();
404        store.set_def_registry(def_store.clone());
405        // It appears in the roster via the global overlay.
406        assert!(store.agent_def_list().unwrap().iter().any(|d| d.id == "remote-1"));
407
408        // Deleting it (rows == 0 locally) must tombstone the global record and
409        // report success — not silently leave it to reappear. (codex P1.)
410        let deleted = store.agent_def_delete("remote-1").unwrap();
411        assert!(deleted, "cross-channel delete must report success");
412        assert!(!def_store.exists("remote-1"), "global record tombstoned");
413        assert!(
414            !store.agent_def_list().unwrap().iter().any(|d| d.id == "remote-1"),
415            "deleted cross-channel agent must not reappear in the roster"
416        );
417    }
418
419    #[test]
420    fn update_edits_a_cross_channel_only_agent_preserving_content_skills() {
421        let store = Store::open_in_memory().unwrap();
422        let tmp = tempfile::tempdir().unwrap();
423        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
424        // Global-only agent WITH content + skills.
425        def_store
426            .upsert(&global_user_agent("remote-1", "Remote"))
427            .unwrap();
428        store.set_def_registry(def_store.clone());
429
430        // Edit it (no local SQLite row → UPDATE affects 0 rows).
431        let mut edited = record_to_agent_definition(&global_user_agent("remote-1", "Remote"));
432        edited.name = "Renamed".to_string();
433        let ok = store.agent_def_update(&mut edited).unwrap();
434        assert!(ok, "cross-channel update must report success, not 'not found'");
435
436        // Global record reflects the edit; content + skills are preserved.
437        let rec = def_store.get("remote-1").unwrap().unwrap();
438        assert_eq!(rec.data.name, "Renamed");
439        assert_eq!(rec.data.content.len(), 1, "content preserved");
440        assert_eq!(rec.data.skills.len(), 1, "skills preserved");
441        // The roster shows the new name.
442        assert!(store
443            .agent_def_list()
444            .unwrap()
445            .iter()
446            .any(|d| d.id == "remote-1" && d.name == "Renamed"));
447    }
448}